page.tsx 73 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api';
  6. import { Avatar } from '@/components/ui/avatar';
  7. import { AssetCard } from '@/components/ui/AssetCard';
  8. import { FolderTree } from '@/components/folders/FolderTree';
  9. import { ShareModal } from '@/components/share/ShareModal';
  10. import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
  11. import { useDropzone } from 'react-dropzone';
  12. async function safeCopy(text: string): Promise<void> {
  13. if (typeof window === 'undefined') return;
  14. try {
  15. const cb = navigator.clipboard;
  16. if (cb && typeof cb.writeText === 'function') {
  17. await cb.writeText(text);
  18. } else {
  19. const el = document.createElement('textarea');
  20. el.value = text;
  21. el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
  22. document.body.appendChild(el);
  23. el.focus(); el.select();
  24. try { document.execCommand('copy'); } catch { /* ignore */ }
  25. document.body.removeChild(el);
  26. }
  27. } catch { /* ignore */ }
  28. }
  29. const ROLE_COLORS: Record<string, string> = {
  30. ADMIN: 'badge-danger',
  31. EDITOR: 'badge-brand',
  32. REVIEWER:'badge-muted',
  33. VIEWER: 'badge-subtle',
  34. };
  35. const ROLE_LABELS: Record<string, string> = {
  36. ADMIN: 'Admin',
  37. EDITOR: 'Editor',
  38. REVIEWER:'Reviewer',
  39. VIEWER: 'Viewer',
  40. };
  41. function formatGroupDate(d: Date): string {
  42. const now = new Date();
  43. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  44. const yesterday = new Date(today.getTime() - 86400000);
  45. const videoDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
  46. if (videoDay.getTime() === today.getTime()) return 'Today';
  47. if (videoDay.getTime() === yesterday.getTime()) return 'Yesterday';
  48. return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
  49. }
  50. function groupByDay(assets: Asset[]): [string, Asset[]][] {
  51. const sorted = [...assets].sort(
  52. (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
  53. );
  54. const groups: Record<string, Asset[]> = {};
  55. for (const a of sorted) {
  56. const d = new Date(a.createdAt);
  57. const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
  58. if (!groups[day]) groups[day] = [];
  59. groups[day].push(a);
  60. }
  61. return Object.entries(groups);
  62. }
  63. /** Collect asset IDs DIRECTLY in a folder (not from subfolders) */
  64. function collectAssetIds(folders: FolderNode[], targetId: string | null): Set<string> {
  65. const ids = new Set<string>();
  66. if (targetId === null) return ids; // "All Videos" — no filter
  67. function findTarget(f: FolderNode): FolderNode | null {
  68. if (f.id === targetId) return f;
  69. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  70. return null;
  71. }
  72. for (const f of folders) {
  73. const target = findTarget(f);
  74. if (target) { for (const id of target.assetIds) ids.add(id); break; }
  75. }
  76. return ids;
  77. }
  78. /** Get direct subfolders of a folder */
  79. function getSubfolders(folders: FolderNode[], targetId: string | null): FolderNode[] {
  80. if (targetId === null) return folders; // root: show top-level folders
  81. function findTarget(f: FolderNode): FolderNode | null {
  82. if (f.id === targetId) return f;
  83. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  84. return null;
  85. }
  86. for (const f of folders) {
  87. const target = findTarget(f);
  88. if (target) return [...target.children].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
  89. }
  90. return [];
  91. }
  92. /** Build a map of assetId -> single deepest folder name */
  93. function buildAssetFolders(allFolders: FolderNode[]): Map<string, string> {
  94. const map = new Map<string, string>();
  95. const depthMap = new Map<string, number>();
  96. function search(f: FolderNode, depth: number): void {
  97. for (const id of f.assetIds) {
  98. const existingDepth = depthMap.get(id) ?? -1;
  99. if (depth > existingDepth) {
  100. map.set(id, f.name);
  101. depthMap.set(id, depth);
  102. }
  103. }
  104. for (const child of f.children) search(child, depth + 1);
  105. }
  106. for (const f of allFolders) search(f, 0);
  107. return map;
  108. }
  109. /** Get the folder name an asset belongs to (deepest only) */
  110. function getAssetFolderNames(assetFolders: Map<string, string>, assetId: string): string[] {
  111. const name = assetFolders.get(assetId);
  112. return name ? [name] : [];
  113. }
  114. /** Returns a breadcrumb path of folder names for the selected folder */
  115. function getBreadcrumb(folders: FolderNode[], targetId: string | null): string[] {
  116. if (targetId === null) return [];
  117. const path: string[] = [];
  118. function search(f: FolderNode, trail: string[]): boolean {
  119. if (f.id === targetId) { path.push(...trail, f.name); return true; }
  120. for (const child of f.children) {
  121. if (search(child, [...trail, f.name])) return true;
  122. }
  123. return false;
  124. }
  125. for (const f of folders) if (search(f, [])) break;
  126. return path;
  127. }
  128. export default function ProjectDetailPage() {
  129. const params = useParams();
  130. const projectId = params.projectId as string;
  131. const { user, token } = useAuth();
  132. const router = useRouter();
  133. const [project, setProject] = useState<Project | null>(null);
  134. const [members, setMembers] = useState<any[]>([]);
  135. const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
  136. const [assets, setAssets] = useState<Asset[]>([]);
  137. const [folders, setFolders] = useState<FolderNode[]>([]);
  138. const [allFolders, setAllFolders] = useState<FolderNode[]>([]);
  139. const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
  140. const [viewMode, setViewMode] = useState<'file' | 'timeline'>('file');
  141. const [loading, setLoading] = useState(true);
  142. const [uploading, setUploading] = useState(false);
  143. const [sharingAssetId, setSharingAssetId] = useState<string | null>(null);
  144. const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
  145. // Invite form state (single shared form)
  146. const [inviteEmail, setInviteEmail] = useState('');
  147. const [inviteRole, setInviteRole] = useState('REVIEWER');
  148. const [inviting, setInviting] = useState(false);
  149. const [inviteError, setInviteError] = useState('');
  150. const [inviteSuccess, setInviteSuccess] = useState('');
  151. const [createdLink, setCreatedLink] = useState('');
  152. const [createdLinkEmail, setCreatedLinkEmail] = useState('');
  153. const [linkCopiedAgain, setLinkCopiedAgain] = useState(false);
  154. // Edit member role
  155. const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
  156. const [editingRole, setEditingRole] = useState('');
  157. const [updatingRole, setUpdatingRole] = useState(false);
  158. // Remove member
  159. const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null);
  160. const [removing, setRemoving] = useState(false);
  161. // Revoke invite
  162. const [revokingId, setRevokingId] = useState<string | null>(null);
  163. // Copy link
  164. const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
  165. const [inviteUrlMap, setInviteUrlMap] = useState<Record<string, string>>({});
  166. const canManage = members.some(m =>
  167. m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
  168. );
  169. const isOwner = project?.ownerId === user?.id;
  170. const isAdmin = members.some(m =>
  171. m.user.id === user?.id && m.role === 'ADMIN'
  172. );
  173. // ── Folder data derived from state ──────────────────────────────────────────
  174. // For file mode: only assets directly in the selected folder
  175. const folderAssetIds = assets.length > 0
  176. ? collectAssetIds(folders, selectedFolderId)
  177. : new Set<string>();
  178. // For timeline mode: assets in selected folder AND all its subfolders
  179. const timelineAssetIds = (() => {
  180. const ids = new Set<string>();
  181. if (selectedFolderId === null) return ids;
  182. function findTarget(f: FolderNode): FolderNode | null {
  183. if (f.id === selectedFolderId) return f;
  184. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  185. return null;
  186. }
  187. function collectAll(f: FolderNode): void {
  188. for (const id of f.assetIds) ids.add(id);
  189. for (const c of f.children) collectAll(c);
  190. }
  191. for (const f of folders) {
  192. const target = findTarget(f);
  193. if (target) { collectAll(target); break; }
  194. }
  195. return ids;
  196. })();
  197. const filteredAssets = selectedFolderId === null
  198. ? assets
  199. : (folderAssetIds.size > 0 ? assets.filter(a => folderAssetIds.has(a.id)) : []);
  200. // Timeline uses all assets in the selected folder AND its subfolders
  201. const timelineAssets = selectedFolderId === null
  202. ? assets
  203. : (timelineAssetIds.size > 0 ? assets.filter(a => timelineAssetIds.has(a.id)) : []);
  204. const subfolders = getSubfolders(folders, selectedFolderId);
  205. const breadcrumb = getBreadcrumb(folders, selectedFolderId);
  206. const assetFolders = buildAssetFolders(allFolders);
  207. // ── Delete project ──────────────────────────────────────────────────────────
  208. const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
  209. const [deletingProject, setDeletingProject] = useState(false);
  210. const handleDeleteProject = async () => {
  211. if (!token) return;
  212. setDeletingProject(true);
  213. try {
  214. await projectsApi.delete(token, projectId);
  215. router.push('/projects');
  216. } catch (err) {
  217. alert(err instanceof Error ? err.message : 'Failed to delete project');
  218. } finally {
  219. setDeletingProject(false);
  220. setConfirmDeleteProject(false);
  221. }
  222. };
  223. const loadFolders = useCallback(async () => {
  224. if (!token) return;
  225. try {
  226. const data = await foldersApi.list(token, projectId);
  227. setFolders(data.folders);
  228. setAllFolders(data.allFolders);
  229. } catch (e) {
  230. console.error('Failed to load folders:', e);
  231. }
  232. }, [token, projectId]);
  233. const loadAll = useCallback(async () => {
  234. if (!token) return;
  235. try {
  236. const [{ project: p }, { assets: a }] = await Promise.all([
  237. projectsApi.get(token, projectId),
  238. assetsApi.list(token, projectId),
  239. ]);
  240. setProject(p);
  241. setMembers(p.members ?? []);
  242. setAssets(a);
  243. if (canManage) {
  244. const { invitations } = await invitationsApi.list(token, projectId);
  245. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  246. }
  247. } catch {
  248. router.push('/projects');
  249. } finally {
  250. setLoading(false);
  251. }
  252. }, [token, projectId, router, canManage]);
  253. useEffect(() => { loadAll(); }, [loadAll]);
  254. useEffect(() => { if (!loading && token) loadFolders(); }, [loading, token, loadFolders]);
  255. // ── Invite member ──────────────────────────────────────────────────────────
  256. const handleInvite = async (e: React.FormEvent) => {
  257. e.preventDefault();
  258. if (!token || !inviteEmail.trim()) return;
  259. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) {
  260. setInviteError('Invalid email address');
  261. return;
  262. }
  263. setInviting(true);
  264. setInviteError('');
  265. setInviteSuccess('');
  266. setCreatedLink('');
  267. try {
  268. const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
  269. const { invitations } = await invitationsApi.list(token, projectId);
  270. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  271. setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl }));
  272. setInviteEmail('');
  273. setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
  274. setTimeout(() => setInviteSuccess(''), 3000);
  275. } catch (err) {
  276. setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
  277. } finally {
  278. setInviting(false);
  279. }
  280. };
  281. // ── Create & copy link ─────────────────────────────────────────────────────
  282. const handleCreateLink = async () => {
  283. if (!token || !inviteEmail.trim()) return;
  284. setInviting(true);
  285. setInviteError('');
  286. setInviteSuccess('');
  287. setCreatedLink('');
  288. setLinkCopiedAgain(false);
  289. const email = inviteEmail.trim();
  290. try {
  291. const { inviteUrl } = await invitationsApi.create(token, projectId, email, inviteRole);
  292. const { invitations } = await invitationsApi.list(token, projectId);
  293. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  294. await safeCopy(inviteUrl);
  295. setCreatedLink(inviteUrl);
  296. setCreatedLinkEmail(email);
  297. setInviteEmail('');
  298. } catch (err: any) {
  299. const msg = err instanceof Error ? err.message : String(err);
  300. if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
  301. setInviteError(`An invitation for "${email}" is already pending or the user is already a member.`);
  302. } else {
  303. setInviteError(msg || 'Failed to create invitation link');
  304. }
  305. } finally {
  306. setInviting(false);
  307. }
  308. };
  309. // ── Change role ────────────────────────────────────────────────────────────
  310. const handleChangeRole = async (memberId: string) => {
  311. if (!token || !editingRole) return;
  312. setUpdatingRole(true);
  313. try {
  314. await projectsApi.updateMember(token, projectId, memberId, editingRole);
  315. setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m));
  316. setEditingRoleId(null);
  317. } catch (err) {
  318. alert(err instanceof Error ? err.message : 'Failed to update role');
  319. } finally {
  320. setUpdatingRole(false);
  321. }
  322. };
  323. // ── Remove member ─────────────────────────────────────────────────────────
  324. const handleRemoveMember = async () => {
  325. if (!token || !confirmRemove) return;
  326. setRemoving(true);
  327. try {
  328. await projectsApi.removeMember(token, projectId, confirmRemove.id);
  329. setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id));
  330. setConfirmRemove(null);
  331. } catch (err) {
  332. alert(err instanceof Error ? err.message : 'Failed to remove member');
  333. } finally {
  334. setRemoving(false);
  335. }
  336. };
  337. // ── Revoke invite ──────────────────────────────────────────────────────────
  338. const handleRevoke = async (invitationId: string) => {
  339. if (!token) return;
  340. setRevokingId(invitationId);
  341. try {
  342. await invitationsApi.revoke(token, invitationId);
  343. setPendingInvites(prev => prev.filter(i => i.id !== invitationId));
  344. } catch (err) {
  345. alert(err instanceof Error ? err.message : 'Failed to revoke invitation');
  346. } finally {
  347. setRevokingId(null);
  348. }
  349. };
  350. // ── Copy invite link ──────────────────────────────────────────────────────
  351. const handleCopyLink = async (invite: Invitation) => {
  352. const base = window.location.origin;
  353. const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
  354. await safeCopy(url);
  355. setCopiedInviteId(invite.id);
  356. setTimeout(() => setCopiedInviteId(null), 2000);
  357. };
  358. // ── Upload ─────────────────────────────────────────────────────────────────
  359. const handleDrop = async (acceptedFiles: File[]) => {
  360. if (!token || acceptedFiles.length === 0) return;
  361. setUploading(true);
  362. for (const file of acceptedFiles) {
  363. const formData = new FormData();
  364. formData.append('video', file);
  365. formData.append('projectId', projectId);
  366. formData.append('title', file.name.replace(/\.[^.]+$/, ''));
  367. try {
  368. const result = await assetsApi.upload(token, formData) as { asset: Asset };
  369. setAssets(prev => [result.asset, ...prev]);
  370. } catch (err) {
  371. console.error('Upload failed:', err);
  372. alert(`Upload failed: ${file.name}`);
  373. }
  374. }
  375. setUploading(false);
  376. };
  377. const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({
  378. onDrop: handleDrop,
  379. accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
  380. multiple: true,
  381. disabled: uploading,
  382. });
  383. // Poll for assets that are still processing
  384. const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
  385. // ── Delete asset ─────────────────────────────────────────────────────────
  386. const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null);
  387. const [deletingId, setDeletingId] = useState<string | null>(null);
  388. const handleDeleteAsset = (id: string, title: string) => {
  389. setConfirmDelete({ id, title });
  390. };
  391. // ── Remove asset from a folder ──────────────────────────────────────────
  392. const handleRemoveFromFolder = useCallback(async (assetId: string, folderName: string) => {
  393. if (!token) return;
  394. // Find the folder by name within the project
  395. const folder = allFolders.find(f => f.name === folderName);
  396. if (!folder) return;
  397. try {
  398. await foldersApi.removeAsset(token, folder.id, assetId);
  399. // Refresh folder data so asset disappears from the folder
  400. loadFolders();
  401. } catch (err) {
  402. console.error('Failed to remove from folder:', err);
  403. }
  404. }, [token, allFolders, loadFolders]);
  405. const confirmDeleteAsset = async () => {
  406. if (!token || !confirmDelete) return;
  407. setDeletingId(confirmDelete.id);
  408. try {
  409. await assetsApi.delete(token, confirmDelete.id);
  410. setAssets(prev => prev.filter(a => a.id !== confirmDelete.id));
  411. setConfirmDelete(null);
  412. } catch (err) {
  413. alert(err instanceof Error ? err.message : 'Failed to delete video');
  414. } finally {
  415. setDeletingId(null);
  416. }
  417. };
  418. useEffect(() => {
  419. const processingAssets = assets.filter(a =>
  420. ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
  421. );
  422. if (processingAssets.length === 0) {
  423. if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
  424. return;
  425. }
  426. if (pollingRef.current) return;
  427. pollingRef.current = setInterval(async () => {
  428. if (!token) return;
  429. try {
  430. const { assets: updated } = await assetsApi.list(token, projectId);
  431. setAssets(updated);
  432. } catch {}
  433. }, 3000);
  434. return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
  435. }, [token, projectId, assets]);
  436. if (loading) {
  437. return (
  438. <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  439. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  440. <div className="w-5 h-5 rounded-full animate-spin"
  441. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  442. <span className="text-sm">Loading…</span>
  443. </div>
  444. </div>
  445. );
  446. }
  447. return (
  448. <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
  449. {/* Full-page upload overlay when dragging files */}
  450. {isUploadDragActive && (
  451. <div {...getUploadRootProps()} className="upload-drop-overlay">
  452. <input {...getUploadInputProps()} />
  453. <div className="text-center">
  454. <div className="w-16 h-16 rounded-2xl mx-auto mb-4 flex items-center justify-center"
  455. style={{ background: 'rgba(99,102,241,0.15)', border: '2px solid rgba(99,102,241,0.4)' }}>
  456. <svg className="w-8 h-8" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  457. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  458. </svg>
  459. </div>
  460. <p className="text-lg font-medium" style={{ color: 'var(--text)' }}>Drop videos to upload</p>
  461. <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>MP4, MOV, WebM — up to 500MB each</p>
  462. </div>
  463. </div>
  464. )}
  465. {/* Header */}
  466. <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-2 md:gap-4 shrink-0 flex-wrap"
  467. style={{
  468. background: 'rgba(10,11,20,0.80)',
  469. backdropFilter: 'blur(12px)',
  470. borderBottom: '1px solid rgba(255,255,255,0.06)',
  471. }}>
  472. <button
  473. onClick={() => router.push('/projects')}
  474. className="flex items-center gap-1.5 text-sm transition-colors shrink-0"
  475. style={{ color: 'var(--text-muted)' }}
  476. >
  477. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  478. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  479. </svg>
  480. <span className="hidden sm:inline">Projects</span>
  481. </button>
  482. <div className="w-px h-4 hidden sm:block shrink-0" style={{ background: 'rgba(255,255,255,0.10)' }} />
  483. <div className="flex-1 min-w-0">
  484. <div className="flex items-center gap-2">
  485. <h1 className="text-sm font-semibold truncate" style={{ color: 'var(--text)' }}>
  486. {project?.name}
  487. </h1>
  488. {canManage && (
  489. <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
  490. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
  491. {isAdmin ? 'Owner' : 'Editor'}
  492. </span>
  493. )}
  494. {!canManage && !isAdmin && (
  495. <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
  496. style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--text-subtle)' }}>
  497. {members.find(m => m.user.id === user?.id)?.role ?? 'Member'}
  498. </span>
  499. )}
  500. </div>
  501. {project?.description && (
  502. <p className="text-xs truncate mt-0.5 hidden sm:block" style={{ color: 'var(--text-muted)' }}>
  503. {project.description}
  504. </p>
  505. )}
  506. </div>
  507. {/* Upload button — compact, in header */}
  508. {canManage && (
  509. <button
  510. {...getUploadRootProps()}
  511. className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg shrink-0 transition-all"
  512. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}
  513. title="Upload video"
  514. >
  515. <input {...getUploadInputProps()} />
  516. {uploading ? (
  517. <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#A5B4FC', borderTopColor: 'transparent', borderWidth: '2px' }} />
  518. ) : (
  519. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  520. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  521. </svg>
  522. )}
  523. <span className="hidden sm:inline">Upload</span>
  524. </button>
  525. )}
  526. {/* Tabs */}
  527. <div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
  528. style={{ background: 'rgba(255,255,255,0.04)' }}>
  529. {[
  530. { tab: 'videos', label: 'Videos', count: assets.length },
  531. { tab: 'transcode', label: 'Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
  532. { tab: 'members', label: 'Members', count: members.length },
  533. ].map(({ tab, label, count }) => (
  534. <button key={tab}
  535. onClick={() => setActiveTab(tab as any)}
  536. className="relative px-2 sm:px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 shrink-0"
  537. style={{
  538. background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
  539. color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
  540. }}
  541. title={label}
  542. >
  543. {tab === 'videos' && (
  544. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  545. <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
  546. </svg>
  547. )}
  548. {tab === 'transcode' && (
  549. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  550. <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
  551. </svg>
  552. )}
  553. {tab === 'members' && (
  554. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  555. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
  556. </svg>
  557. )}
  558. <span className="hidden sm:inline">{label}</span>
  559. <span className="text-[10px] px-1 py-0.5 rounded-full"
  560. style={{
  561. background: tab === 'transcode'
  562. ? 'rgba(167,139,250,0.25)'
  563. : 'rgba(255,255,255,0.06)',
  564. color: tab === 'transcode' ? '#A78BFA' : 'inherit',
  565. }}>
  566. {count}
  567. </span>
  568. </button>
  569. ))}
  570. </div>
  571. {/* Delete project — owner only */}
  572. {isOwner && (
  573. <button
  574. onClick={() => setConfirmDeleteProject(true)}
  575. className="flex items-center justify-center p-1.5 rounded-lg transition-all shrink-0"
  576. style={{ color: '#F87171', background: 'rgba(248,113,113,0.08)' }}
  577. title="Delete project"
  578. >
  579. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  580. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  581. </svg>
  582. </button>
  583. )}
  584. </header>
  585. <div className="px-4 md:px-8 py-4 md:py-6">
  586. {/* ── Videos Tab ───────────────────────────────────────────────────── */}
  587. {activeTab === 'videos' && (
  588. <>
  589. {/* File/Timeline mode toggle + breadcrumb bar */}
  590. {activeTab === 'videos' && (
  591. <div className="flex items-center gap-3 mb-5 flex-wrap">
  592. {/* Breadcrumb */}
  593. <nav className="flex items-center gap-1 text-xs shrink min-w-0" style={{ color: 'var(--text-muted)' }}>
  594. <span className="truncate">{project?.name}</span>
  595. {breadcrumb.map((name, i) => (
  596. <span key={i} className="flex items-center gap-1 shrink-0">
  597. <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  598. <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
  599. </svg>
  600. <span className={i === breadcrumb.length - 1 ? '' : 'opacity-60'}>{name}</span>
  601. </span>
  602. ))}
  603. </nav>
  604. <div className="flex-1" />
  605. {/* Asset count */}
  606. <span className="text-xs px-2 py-1 rounded-full shrink-0"
  607. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  608. {filteredAssets.length} video{filteredAssets.length !== 1 ? 's' : ''}
  609. </span>
  610. {/* Mode toggle */}
  611. <div className="flex items-center gap-0.5 p-0.5 rounded-lg shrink-0"
  612. style={{ background: 'rgba(255,255,255,0.05)' }}>
  613. {[
  614. { mode: 'file' as const, label: 'File', icon: (
  615. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  616. <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-1.5A2.25 2.25 0 0115 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
  617. </svg>
  618. )},
  619. { mode: 'timeline' as const, label: 'Timeline', icon: (
  620. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  621. <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
  622. </svg>
  623. )},
  624. ].map(({ mode, label, icon }) => (
  625. <button key={mode}
  626. onClick={() => setViewMode(mode)}
  627. className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap"
  628. style={{
  629. background: viewMode === mode ? 'rgba(99,102,241,0.20)' : 'transparent',
  630. color: viewMode === mode ? '#A5B4FC' : 'var(--text-muted)',
  631. }}
  632. >
  633. {icon}
  634. <span className="hidden sm:inline">{label}</span>
  635. </button>
  636. ))}
  637. </div>
  638. </div>
  639. )}
  640. <div className="flex gap-5">
  641. {/* Left panel: FolderTree (both file and timeline modes) */}
  642. <aside className="w-52 shrink-0 hidden md:block">
  643. <FolderTree
  644. folders={folders}
  645. allFolders={allFolders}
  646. selectedFolderId={selectedFolderId}
  647. onSelectFolder={setSelectedFolderId}
  648. canManage={canManage}
  649. token={token ?? ''}
  650. projectId={projectId}
  651. onRefresh={loadFolders}
  652. totalAssetCount={assets.length}
  653. />
  654. </aside>
  655. {/* Main content */}
  656. <div className="flex-1 min-w-0">
  657. {/* Upload zone for non-managers */}
  658. {!canManage && (
  659. <div className="mb-6 rounded-2xl p-6 text-center animate-fade-in"
  660. style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
  661. <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
  662. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
  663. <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  664. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  665. </svg>
  666. </div>
  667. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  668. Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading.
  669. </p>
  670. </div>
  671. )}
  672. {/* File mode content */}
  673. {viewMode === 'file' && (filteredAssets.length === 0 && subfolders.length === 0) ? (
  674. <div className="text-center py-16 rounded-2xl animate-fade-in"
  675. style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
  676. <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
  677. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  678. <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
  679. <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
  680. </svg>
  681. </div>
  682. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
  683. {selectedFolderId ? 'No videos in this folder' : 'No videos yet'}
  684. </p>
  685. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  686. {selectedFolderId
  687. ? 'Drag videos here or move them from other folders'
  688. : (canManage ? 'Upload your first video using the Upload button above' : 'Videos will appear here once uploaded')}
  689. </p>
  690. </div>
  691. ) : viewMode === 'file' ? (
  692. // File mode: subfolders + videos
  693. <div>
  694. {/* Subfolders */}
  695. {subfolders.length > 0 && (
  696. <div className="mb-6">
  697. <div className="flex items-center gap-3 mb-3">
  698. <span className="text-xs font-medium" style={{ color: 'var(--text-subtle)' }}>Folders</span>
  699. <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.05)' }} />
  700. </div>
  701. <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
  702. {subfolders.map(folder => (
  703. <button
  704. key={folder.id}
  705. onClick={() => setSelectedFolderId(folder.id)}
  706. className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-left transition-all hover:brightness-110 group"
  707. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}
  708. >
  709. <svg className="w-5 h-5 shrink-0" style={{ color: '#A78BFA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  710. <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9A2.25 2.25 0 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
  711. </svg>
  712. <div className="flex-1 min-w-0">
  713. <div className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{folder.name}</div>
  714. {folder.assetCount > 0 && (
  715. <div className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>{folder.assetCount} video{folder.assetCount !== 1 ? 's' : ''}</div>
  716. )}
  717. </div>
  718. <svg className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  719. <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
  720. </svg>
  721. </button>
  722. ))}
  723. </div>
  724. </div>
  725. )}
  726. {/* Videos in this folder */}
  727. {filteredAssets.length > 0 && (
  728. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
  729. {filteredAssets.map((asset, i) => (
  730. <AssetCard
  731. key={asset.id}
  732. asset={asset}
  733. canManage={canManage}
  734. showHour={false}
  735. onPlay={() => router.push(`/review/${asset.id}`)}
  736. onDelete={() => handleDeleteAsset(asset.id, asset.title)}
  737. onCancel={async (id) => {
  738. if (!token) return;
  739. try {
  740. await assetsApi.cancelTranscode(token, id);
  741. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  742. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
  743. }}
  744. onPause={async (id) => {
  745. if (!token) return;
  746. try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
  747. catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
  748. }}
  749. onResume={async (id) => {
  750. if (!token) return;
  751. try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
  752. catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
  753. }}
  754. animationDelay={i * 40}
  755. folderNames={getAssetFolderNames(assetFolders, asset.id)}
  756. onShare={setSharingAssetId}
  757. isShared={!!asset.isShared}
  758. onRemoveFromFolder={handleRemoveFromFolder}
  759. />
  760. ))}
  761. </div>
  762. )}
  763. </div>
  764. ) : (
  765. // Timeline mode: grouped by date
  766. <div className="space-y-8">
  767. {groupByDay(timelineAssets).map(([dayKey, dayAssets]) => {
  768. const groupDate = new Date(dayKey);
  769. const showHour = dayAssets.length > 1;
  770. return (
  771. <div key={dayKey}>
  772. <div className="flex items-center gap-3 mb-4">
  773. <span className="text-xs font-semibold shrink-0" style={{ color: 'var(--text-muted)' }}>
  774. {formatGroupDate(groupDate)}
  775. </span>
  776. <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.06)' }} />
  777. <span className="text-[10px] shrink-0" style={{ color: 'var(--text-subtle)' }}>
  778. {dayAssets.length} video{dayAssets.length !== 1 ? 's' : ''}
  779. </span>
  780. </div>
  781. <div className="space-y-3">
  782. {dayAssets.map((asset, i) => {
  783. const createdAt = new Date(asset.createdAt);
  784. return (
  785. <div key={asset.id}
  786. className="flex items-center gap-4 p-3 rounded-xl cursor-pointer group transition-colors animate-fade-in"
  787. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}
  788. onClick={() => router.push(`/review/${asset.id}`)}
  789. draggable={canManage}
  790. onDragStart={canManage ? (e) => {
  791. e.dataTransfer.setData('assetId', asset.id);
  792. e.dataTransfer.setData('text/plain', asset.title);
  793. e.dataTransfer.effectAllowed = 'move';
  794. if (asset.thumbnail && asset.transcodeStatus === 'COMPLETED') {
  795. const ghost = document.createElement('div');
  796. ghost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(15,15,25,0.95);border:1px solid rgba(99,102,241,0.4);border-radius:8px;backdrop-filter:blur(8px);font-family:system-ui,sans-serif;z-index:99999;';
  797. const img = document.createElement('img');
  798. img.src = `/uploads/${asset.thumbnail}`;
  799. img.style.cssText = 'height:48px;border-radius:5px;object-fit:cover;';
  800. const label = document.createElement('span');
  801. label.textContent = asset.title;
  802. label.style.cssText = 'color:#e2e8f0;font-size:12px;font-weight:500;max-width:160px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;';
  803. ghost.appendChild(img);
  804. ghost.appendChild(label);
  805. document.body.appendChild(ghost);
  806. e.dataTransfer.setDragImage(ghost, 30, 28);
  807. setTimeout(() => document.body.removeChild(ghost), 0);
  808. }
  809. } : undefined}
  810. >
  811. {/* Thumbnail */}
  812. <div className="w-24 sm:w-32 shrink-0 rounded-lg overflow-hidden aspect-video"
  813. style={{ background: '#080810' }}>
  814. {asset.thumbnail && asset.transcodeStatus === 'COMPLETED' ? (
  815. <img src={`/uploads/${asset.thumbnail}`} alt={asset.title} className="w-full h-full object-cover" style={{ opacity: 0.8 }} />
  816. ) : (
  817. <div className="w-full h-full flex items-center justify-center">
  818. <svg className="w-6 h-6" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
  819. <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
  820. <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  821. </svg>
  822. </div>
  823. )}
  824. </div>
  825. {/* Info */}
  826. <div className="flex-1 min-w-0">
  827. <div className="flex items-start justify-between gap-2 mb-1">
  828. <h3 className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h3>
  829. {asset.duration && (
  830. <span className="text-xs shrink-0 px-1.5 py-0.5 rounded font-mono"
  831. style={{ background: 'rgba(0,0,0,0.5)', color: '#E2E8F0' }}>
  832. {`${Math.floor(asset.duration / 60)}:${Math.floor(asset.duration % 60).toString().padStart(2, '0')}`}
  833. </span>
  834. )}
  835. </div>
  836. {/* Folder tags */}
  837. {(() => {
  838. const tags = getAssetFolderNames(assetFolders, asset.id);
  839. return tags.length > 0 ? (
  840. <div className="flex flex-wrap gap-1 mb-1">
  841. {tags.map((name, i) => (
  842. <span key={i} className="text-[10px] px-1.5 py-0.5 rounded"
  843. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
  844. {name}
  845. </span>
  846. ))}
  847. </div>
  848. ) : null;
  849. })()}
  850. <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
  851. <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
  852. <span>·</span>
  853. <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>
  854. {showHour
  855. ? createdAt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
  856. : createdAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  857. </span>
  858. <span>·</span>
  859. <span>{(asset as any)._count?.comments ?? 0} comments</span>
  860. </div>
  861. </div>
  862. {/* Play button */}
  863. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
  864. style={{ background: 'rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
  865. <svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
  866. <path d="M8 5v14l11-7z" />
  867. </svg>
  868. </div>
  869. </div>
  870. );
  871. })}
  872. </div>
  873. </div>
  874. );
  875. })}
  876. </div>
  877. )}
  878. </div>
  879. </div>
  880. </>
  881. )}
  882. {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */}
  883. {activeTab === 'transcode' && (
  884. <div className="animate-fade-in">
  885. <TranscodeTasksPanel
  886. assets={assets}
  887. token={token}
  888. canManage={canManage}
  889. onDelete={handleDeleteAsset}
  890. onCancel={async (id) => {
  891. if (!token) return;
  892. try {
  893. await assetsApi.cancelTranscode(token, id);
  894. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  895. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
  896. }}
  897. onPause={async (id) => {
  898. if (!token) return;
  899. try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
  900. catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
  901. }}
  902. onResume={async (id) => {
  903. if (!token) return;
  904. try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
  905. catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
  906. }}
  907. onReprocess={async (id) => {
  908. if (!token) return;
  909. try {
  910. await assetsApi.cancelTranscode(token, id);
  911. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  912. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to reprocess transcode'); }
  913. }}
  914. />
  915. </div>
  916. )}
  917. {/* ── Members Tab ─────────────────────────────────────────────────── */}
  918. {activeTab === 'members' && (
  919. <div className="max-w-3xl animate-fade-in">
  920. {/* Invite form */}
  921. {canManage && (
  922. <div className="card p-5 mb-6">
  923. <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
  924. Invite someone
  925. </h2>
  926. <div className="space-y-3">
  927. <form
  928. onSubmit={e => { e.preventDefault(); handleInvite(e); }}
  929. className="flex items-end gap-3 flex-wrap"
  930. >
  931. <div className="flex-1 min-w-[180px]">
  932. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
  933. Email address
  934. </label>
  935. <input
  936. type="email"
  937. className="input"
  938. value={inviteEmail}
  939. onChange={e => setInviteEmail(e.target.value)}
  940. placeholder="colleague@company.com"
  941. />
  942. </div>
  943. <div className="w-36">
  944. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
  945. <select
  946. className="input"
  947. value={inviteRole}
  948. onChange={e => setInviteRole(e.target.value)}
  949. >
  950. {Object.entries(ROLE_LABELS).map(([value, label]) => (
  951. <option key={value} value={value}>{label}</option>
  952. ))}
  953. </select>
  954. </div>
  955. <button
  956. type="button"
  957. disabled={inviting || !inviteEmail.trim()}
  958. onClick={handleCreateLink}
  959. className="btn btn-secondary btn-md"
  960. title="Create invite link and copy to clipboard"
  961. >
  962. {inviting ? 'Creating…' : (
  963. <span className="flex items-center gap-1.5">
  964. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  965. <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
  966. </svg>
  967. Copy Link
  968. </span>
  969. )}
  970. </button>
  971. <button
  972. type="submit"
  973. disabled={inviting || !inviteEmail.trim()}
  974. className="btn btn-primary btn-md"
  975. title="Send invite"
  976. >
  977. {inviting ? 'Sending…' : 'Send Invite'}
  978. </button>
  979. </form>
  980. {createdLink && (
  981. <div className="rounded-lg p-4 animate-scale-in"
  982. style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
  983. <div className="flex items-center gap-2 mb-1.5">
  984. <svg className="w-4 h-4 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  985. <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  986. </svg>
  987. <span className="text-sm font-medium" style={{ color: '#86EFAC' }}>Invitation link created!</span>
  988. <button
  989. type="button"
  990. onClick={async () => {
  991. await safeCopy(createdLink);
  992. setLinkCopiedAgain(true);
  993. setTimeout(() => setLinkCopiedAgain(false), 2000);
  994. }}
  995. className="ml-auto text-xs px-3 py-1 rounded-lg transition-all"
  996. style={{ background: 'rgba(255,255,255,0.06)', color: linkCopiedAgain ? '#86EFAC' : 'var(--text-muted)' }}
  997. >
  998. {linkCopiedAgain ? '✓ Copied' : 'Copy link'}
  999. </button>
  1000. </div>
  1001. <p className="text-[10px] mb-2" style={{ color: 'rgba(134,239,172,0.5)' }}>
  1002. Invite sent to <strong style={{ color: '#86EFAC' }}>{createdLinkEmail}</strong> as {inviteRole} · Link expires in 7 days
  1003. </p>
  1004. <p className="text-xs break-all font-mono" style={{ color: 'rgba(134,239,172,0.7)' }}>
  1005. {createdLink}
  1006. </p>
  1007. <p className="text-[10px] mt-2" style={{ color: 'rgba(134,239,172,0.45)' }}>
  1008. Share this link with your colleague — they can use it to join the project directly.
  1009. </p>
  1010. </div>
  1011. )}
  1012. {inviteError && <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>}
  1013. {inviteSuccess && <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>}
  1014. </div>
  1015. </div>
  1016. )}
  1017. {/* Members list */}
  1018. <div className="card overflow-hidden mb-6">
  1019. <div className="px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  1020. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
  1021. Members ({members.length})
  1022. </h2>
  1023. </div>
  1024. {members.length === 0 ? (
  1025. <div className="p-8 text-center">
  1026. <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No members yet</p>
  1027. </div>
  1028. ) : (
  1029. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1030. {members.map(m => {
  1031. const isMe = m.user.id === user?.id;
  1032. const canEdit = isAdmin && !isMe;
  1033. return (
  1034. <div key={m.id}
  1035. className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
  1036. <Avatar name={m.user.name} src={m.user.avatarUrl} size="md" />
  1037. <div className="flex-1 min-w-0">
  1038. <div className="flex items-center gap-2">
  1039. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
  1040. {m.user.name}
  1041. {isMe && <span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-subtle)' }}>(you)</span>}
  1042. </span>
  1043. </div>
  1044. <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
  1045. </div>
  1046. <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
  1047. {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
  1048. </span>
  1049. {editingRoleId === m.id ? (
  1050. <div className="flex items-center gap-2 shrink-0">
  1051. <select
  1052. className="input text-xs py-1.5"
  1053. value={editingRole}
  1054. onChange={e => setEditingRole(e.target.value)}
  1055. autoFocus
  1056. >
  1057. {Object.entries(ROLE_LABELS).map(([v, l]) => (
  1058. <option key={v} value={v}>{l}</option>
  1059. ))}
  1060. </select>
  1061. <button onClick={() => handleChangeRole(m.id)} disabled={updatingRole} className="btn btn-primary btn-sm px-2" title="Save">
  1062. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1063. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1064. </svg>
  1065. </button>
  1066. <button onClick={() => setEditingRoleId(null)} className="btn btn-secondary btn-sm px-2" title="Cancel">
  1067. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1068. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1069. </svg>
  1070. </button>
  1071. </div>
  1072. ) : (
  1073. <div className="flex items-center gap-2 shrink-0">
  1074. <span className={`badge ${ROLE_COLORS[m.role] ?? 'badge-muted'}`}>
  1075. {ROLE_LABELS[m.role] ?? m.role}
  1076. </span>
  1077. {canEdit && (
  1078. <button
  1079. onClick={() => { setEditingRoleId(m.id); setEditingRole(m.role); }}
  1080. className="btn btn-secondary btn-sm"
  1081. title="Change role"
  1082. >
  1083. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1084. <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
  1085. </svg>
  1086. </button>
  1087. )}
  1088. {isAdmin && !isMe && (
  1089. <button
  1090. onClick={() => setConfirmRemove({ id: m.user.id, name: m.user.name })}
  1091. className="btn btn-danger btn-sm"
  1092. title="Remove from project"
  1093. >
  1094. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1095. <path strokeLinecap="round" strokeLinejoin="round" d="M22 12h-4l-3 9L9 3l-3 9H2" />
  1096. </svg>
  1097. </button>
  1098. )}
  1099. </div>
  1100. )}
  1101. </div>
  1102. );
  1103. })}
  1104. </div>
  1105. )}
  1106. </div>
  1107. {/* Pending invitations */}
  1108. {canManage && (
  1109. <div className="card overflow-hidden">
  1110. <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  1111. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
  1112. Pending invitations
  1113. </h2>
  1114. <span className="text-xs px-2 py-0.5 rounded-full"
  1115. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  1116. {pendingInvites.length}
  1117. </span>
  1118. </div>
  1119. {pendingInvites.length === 0 ? (
  1120. <div className="p-8 text-center">
  1121. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>No pending invitations</p>
  1122. </div>
  1123. ) : (
  1124. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1125. {pendingInvites.map(inv => (
  1126. <div key={inv.id}
  1127. className="flex items-center gap-4 px-5 py-4">
  1128. <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
  1129. style={{ background: 'rgba(99,102,241,0.08)' }}>
  1130. <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  1131. <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
  1132. </svg>
  1133. </div>
  1134. <div className="flex-1 min-w-0">
  1135. <div className="flex items-center gap-2">
  1136. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
  1137. <span className={`badge ${ROLE_COLORS[inv.role] ?? 'badge-muted'}`}>
  1138. {ROLE_LABELS[inv.role] ?? inv.role}
  1139. </span>
  1140. </div>
  1141. <div className="flex items-center gap-3 mt-0.5 text-xs" style={{ color: 'var(--text-subtle)' }}>
  1142. <span>Sent {new Date(inv.createdAt).toLocaleDateString()}</span>
  1143. <span>·</span>
  1144. <span>Expires {new Date(inv.expiresAt).toLocaleDateString()}</span>
  1145. </div>
  1146. </div>
  1147. <div className="flex items-center gap-1.5 shrink-0">
  1148. <button
  1149. onClick={() => handleCopyLink(inv)}
  1150. className="btn btn-secondary btn-sm"
  1151. title="Copy invite link"
  1152. >
  1153. {copiedInviteId === inv.id ? (
  1154. <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1155. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1156. </svg>
  1157. ) : (
  1158. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1159. <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
  1160. </svg>
  1161. )}
  1162. </button>
  1163. <button
  1164. onClick={() => handleRevoke(inv.id)}
  1165. disabled={revokingId === inv.id}
  1166. className="btn btn-danger btn-sm"
  1167. title="Revoke invitation"
  1168. >
  1169. {revokingId === inv.id ? '…' : (
  1170. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1171. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1172. </svg>
  1173. )}
  1174. </button>
  1175. </div>
  1176. </div>
  1177. ))}
  1178. </div>
  1179. )}
  1180. {pendingInvites.length > 0 && (
  1181. <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1182. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  1183. Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.
  1184. </p>
  1185. </div>
  1186. )}
  1187. </div>
  1188. )}
  1189. </div>
  1190. )}
  1191. </div>
  1192. {/* Share modal */}
  1193. {sharingAssetId && (
  1194. <ShareModal
  1195. assetId={sharingAssetId}
  1196. onClose={() => setSharingAssetId(null)}
  1197. />
  1198. )}
  1199. {/* Delete asset confirm modal */}
  1200. {confirmDelete && (
  1201. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1202. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1203. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1204. <div className="flex items-center gap-3 mb-4">
  1205. <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
  1206. style={{ background: 'rgba(248,113,113,0.15)' }}>
  1207. <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1208. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  1209. </svg>
  1210. </div>
  1211. <div>
  1212. <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>Delete video?</h3>
  1213. <p className="text-xs mt-0.5 truncate max-w-[220px]" style={{ color: 'var(--text-muted)' }}>
  1214. "{confirmDelete.title}"
  1215. </p>
  1216. </div>
  1217. </div>
  1218. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1219. This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
  1220. </p>
  1221. <div className="flex gap-3 justify-end">
  1222. <button onClick={() => setConfirmDelete(null)} disabled={!!deletingId} className="btn btn-secondary btn-md">Cancel</button>
  1223. <button onClick={confirmDeleteAsset} disabled={!!deletingId} className="btn btn-danger btn-md">
  1224. {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
  1225. </button>
  1226. </div>
  1227. </div>
  1228. </div>
  1229. )}
  1230. {/* Remove member confirm modal */}
  1231. {confirmRemove && (
  1232. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1233. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1234. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1235. <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
  1236. Remove {confirmRemove.name}?
  1237. </h3>
  1238. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1239. They'll lose access to this project and all its videos. They can rejoin if invited again.
  1240. </p>
  1241. <div className="flex gap-3 justify-end">
  1242. <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">Cancel</button>
  1243. <button onClick={handleRemoveMember} disabled={removing} className="btn btn-danger btn-md">
  1244. {removing ? 'Removing…' : 'Remove'}
  1245. </button>
  1246. </div>
  1247. </div>
  1248. </div>
  1249. )}
  1250. {/* Delete project confirm modal */}
  1251. {confirmDeleteProject && (
  1252. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1253. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1254. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1255. <div className="flex items-center gap-3 mb-4">
  1256. <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
  1257. style={{ background: 'rgba(248,113,113,0.15)' }}>
  1258. <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1259. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  1260. </svg>
  1261. </div>
  1262. <div>
  1263. <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>
  1264. Delete "{project?.name}"?
  1265. </h3>
  1266. <p className="text-xs mt-0.5" style={{ color: '#F87171' }}>
  1267. {assets.length} video{assets.length !== 1 ? 's' : ''} will be permanently deleted
  1268. </p>
  1269. </div>
  1270. </div>
  1271. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1272. This will permanently delete the project, all videos, comments, and assets. This action cannot be undone.
  1273. </p>
  1274. <div className="flex gap-3 justify-end">
  1275. <button
  1276. onClick={() => setConfirmDeleteProject(false)}
  1277. disabled={deletingProject}
  1278. className="btn btn-secondary btn-md"
  1279. >
  1280. Cancel
  1281. </button>
  1282. <button
  1283. onClick={handleDeleteProject}
  1284. disabled={deletingProject}
  1285. className="btn btn-danger btn-md"
  1286. >
  1287. {deletingProject ? 'Deleting…' : 'Delete Project'}
  1288. </button>
  1289. </div>
  1290. </div>
  1291. </div>
  1292. )}
  1293. </div>
  1294. );
  1295. }